DecimalFormat   A
last analyzed

Complexity

Total Complexity 37

Size/Duplication

Total Lines 298
Duplicated Lines 0 %

Test Coverage

Coverage 92.86%

Importance

Changes 0
Metric Value
wmc 37
eloc 181
dl 0
loc 298
ccs 104
cts 112
cp 0.9286
c 0
b 0
f 0
rs 9.44

2 Functions

Rating   Name   Duplication   Size   Complexity  
F supportedLocales 0 11 35
A supportedLocalesOf 0 18 2
1
/*!
2
 * Copyright (c) 2022 Pedro José Batista, licensed under the MIT License.
3
 * See the LICENSE.md file in the project root for more information.
4
 */
5 1
import Decimal from "decimal.js";
6
import type BaseFormatOptions from "./baseOptions";
7
import type CompactDisplay from "./compactDisplay";
8 1
import { BIGINT_MODIFIERS, ECMA_LIMIT, PLAIN_MODIFIERS, SUPPORTED_LOCALES } from "./constants";
9
import type Currency from "./currency";
10
import type CurrencyDisplay from "./currencyDisplay";
11
import type CurrencySign from "./currencySign";
12
import type Locale from "./locale";
13
import type LocaleMatcher from "./localeMatcher";
14
import type Notation from "./notation";
15
import type NumberingSystem from "./numberingSystem";
16
import type FormatOptions from "./options";
17 1
import { extend, resolve, toEcma, validate } from "./options";
18
import type FormatPart from "./part";
19 1
import { exponents, fractions, integerGroups, integers, PartValue } from "./part";
20
import type FormatPartTypes from "./partTypes";
21
import type ResolvedFormatOptions from "./resolvedOptions";
22
import type SignDisplay from "./signDisplay";
23
import type Style from "./style";
24
import type TrailingZeroDisplay from "./trailingZeroDisplay";
25
import type Unit from "./unit";
26
import type UnitDisplay from "./unitDisplay";
27
import type UseGrouping from "./useGrouping";
28
29 1
const concatenate = <T extends PartValue>(filter: T[] | ((p: T) => boolean), parts: T[] = []) => {
30 21693
    if (typeof filter === "function") {
31 16636
        parts = parts.filter(filter);
32
    } else {
33 5057
        parts = filter;
34
    }
35
36 30111
    return parts.map(p => p.value).join("");
37
};
38
39 40
const pow10 = (exponent: Decimal.Value) => Decimal.pow(10, exponent);
40
41
/**
42
 * The `Decimal.Format` object enables language-sensitive decimal number formatting. It is entirely based on
43
 * `Intl.NumberFormat`, with the options of the latter being 100% compatible with it.
44
 *
45
 * This class, however, extend the numeric digits constraints of `Intl.NumberFormat` from 21 to 1000000000 in
46
 * order to fully take advantage of the arbitrary-precision of `decimal.js`.
47
 *
48
 * @template N Numeric notation of formatting.
49
 * @template S Numeric style of formatting.
50
 */
51 1
export class DecimalFormat<N extends Notation, S extends Style> {
52 1
    static readonly [Symbol.toPrimitive] = DecimalFormat;
53 254
    readonly [Symbol.toStringTag] = "Decimal.Format";
54
55
    /**
56
     * Formats a number according to the locale and formatting options of this {@link DecimalFormat} object.
57
     *
58
     * @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format.
59
     * @returns Formatted localized string.
60
     */
61
    readonly format: (value: Decimal.Value) => string;
62
63
    /**
64
     * Allows locale-aware formatting of strings produced by `Decimal.Format` formatters.
65
     *
66
     * @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format.
67
     * @returns An array of objects containing the formatted number in parts.
68
     */
69
    readonly formatToParts: (value: Decimal.Value) => FormatPart[];
70
71
    /**
72
     * Returns a new object with properties reflecting the locale and number formatting options computed during
73
     * initialization of this {@link Decimal.Format} object.
74
     *
75
     * @returns A new object with properties reflecting the locale and number formatting options computed
76
     *   during the initialization of this object.
77
     */
78
    readonly resolvedOptions: () => ResolvedFormatOptions<N, S>;
79
80
    /**
81
     * Creates a new instance of the `Decimal.Format` object.
82
     *
83
     * @param locales A string with a [BCP 47](https://www.rfc-editor.org/info/bcp47) language tag, or an array
84
     *   of such strings.
85
     *
86
     *   For the general form and interpretation of this parameter, see the [Intl page on
87
     *   MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
88
     * @param options Object used to configure the behavior of the string localization.
89
     * @throws `RangeError` when an invalid option is given.
90
     */
91
    constructor(locales?: Locale | Locale[], options?: FormatOptions<N, S>) {
92 254
        options ??= {};
93
94
        // 1. Check if options do not extrapolate the limits of decimal.js
95 254
        const valid = validate(options);
96
97 254
        if (valid !== true) {
98
            // -> it will either be exactly true or contain an array with all faulty properties:
99 5
            throw new RangeError(`${valid.join()} value${valid.length === 1 ? " is" : "s are"} out of range."`);
100
        }
101
102
        // 2. Create a baseline native formatter native
103 249
        const ecmaOptions = toEcma(options);
104 249
        const ecmaFormat = new Intl.NumberFormat(locales, ecmaOptions);
105
106
        // 3. Resolve this object's options, using the native resolution as a baseline
107 249
        const resolved = resolve(options, ecmaFormat.resolvedOptions());
108 249
        const { minimumIntegerDigits: minID, notation, rounding, style } = resolved;
109
110
        // 4. Create two auxiliary formatters:
111
        // One for the integer part, which can have up to a billion minimum digits...
112 249
        const bigintOptions = extend(ecmaOptions, BIGINT_MODIFIERS);
113 249
        const bigintFormat = new Intl.NumberFormat(locales, bigintOptions);
114
115
        // ...and another for a plain, localized reference, used for decimals and constants
116 249
        const plainOptions = extend(bigintOptions, PLAIN_MODIFIERS);
117 249
        const plainFormat = new Intl.NumberFormat(locales, plainOptions);
118
119
        // 5. Localized numeric constants
120 249
        const numbers = Array(10)
121
            .fill(null)
122 2490
            .map((_, index) => plainFormat.format(index));
123 249
        const numberMatch = new RegExp("[" + numbers.join("") + "]", "g");
124 249
        const minusSign = /−/gu;
125
126
        // 5.1. Localized zero and one used in substitutions
127 249
        const [zero, one] = numbers;
128
129
        // 5.2. Helper functions
130 249
        const indexOfValue = (value: string) => numbers.indexOf(value).toString();
131 249
        const convert = (text: string) => text.replaceAll(numberMatch, indexOfValue).replaceAll(minusSign, "-");
132 249
        const zeroFill = (size: number) => Array(size).fill(zero).join("");
133 249
        const zeroTrim = (text: string, mode: "both" | "left" | "right" = "left", max: number | boolean = false) => {
134 4159
            let result = text;
135 4159
            let count = 0;
136
137 4159
            if (mode === "both" || mode === "left")
138 4159
                while (result[0] === zero && result.length > 1 && (max === false || count < max)) {
139 335
                    result = result.slice(1);
140 335
                    count++;
141
                }
142
143 4159
            if (mode === "both" || mode === "right")
144 4
                while (result[result.length - 1] === zero && result.length > 1 && (max === false || count < max)) {
145
                    result = result.slice(0, -1);
146
                    count++;
147
                }
148
149 4159
            return result;
150
        };
151
152
        // #region Step 6. Main format method - - - - - - - - - - - - - - - - - - - - - - - - - - - -
153 249
        const _formatToParts = (value: Decimal.Value) => {
154 5079
            value = new Decimal(value);
155 5079
            const sign = Decimal.sign(value);
156
157
            // 6.1. Create a baseline part array
158 5079
            const ecmaParts = ecmaFormat.formatToParts(value.toNumber());
159
160
            // -> if the value is non-numeric or an infinity, the baseline is good enough
161 5079
            if ((value.isFinite && !value.isFinite()) || (value.isNaN && value.isNaN())) {
162 920
                return ecmaParts;
163
            }
164
165
            // 6.2. Splitting the parts for easier assembly
166 4159
            const ecmaExponentValue = concatenate(exponents, ecmaParts) || "0";
167 4159
            const ecmaIntegerParts = ecmaParts.filter(integerGroups);
168 4159
            const ecmaIntegerTrimmed = zeroTrim(concatenate(integers, ecmaIntegerParts));
169 4159
            const ecmaIntegerDigits = concatenate(integers, ecmaIntegerParts).length;
170 4159
            const ecmaIntegerTrimmedDigits = ecmaIntegerTrimmed.length;
171 4159
            const ecmaFractionValue = concatenate(fractions, ecmaParts);
172 4159
            const ecmaFractionDigits = ecmaFractionValue.length;
173
174
            // 6.3. Shifting exponents according to notation/style
175
176
            // 6.3.1. Compact notation: calculate the shift in integer digits, and therefore exponent
177 4159
            if (notation === "compact" && !value.eq(0)) {
178
                const baseInteger = value.abs().trunc().toFixed();
179
                const baseIntegerDigits = baseInteger.length;
180
                const correctionDigits = baseIntegerDigits - ecmaIntegerTrimmedDigits;
181
182 2
                if (correctionDigits > 0) {
183
                    value = value.mul(pow10(-correctionDigits));
184
                }
185
            }
186
187
            // 6.3.2. Engr./Scientific notations: evaluate the exponent from the text
188 4159
            if ((notation === "engineering" || notation === "scientific") && ecmaExponentValue !== zero) {
189 4
                const exponential = new Decimal(convert(ecmaExponentValue));
190 4
                value = value.mul(pow10(exponential.mul(-1))).abs().mul(sign); // prettier-ignore
191
            }
192
193
            // 6.3.3. Percent style: shift the value accordingly (non numeric parts will remain the same)
194 4159
            if (style === "percent") value = value.mul(100);
195
196
            // 6.4. Parsing the information about the numeric parts
197 4159
            const integer = value.abs().trunc().mul(sign);
198 4159
            const fraction = value.sub(integer).abs();
199 4159
            const integerDigits = !value.eq(0) && integer.eq(0) ? 0 : value.abs().trunc().toFixed().length;
200 4159
            const fractionDigits = value.dp();
201 4159
            const maxSD = resolved.maximumSignificantDigits ?? integerDigits + fractionDigits;
202 4159
            const maxFD = resolved.maximumFractionDigits ?? fractionDigits;
203 4159
            const minSD = resolved.minimumSignificantDigits ?? resolved.minimumFractionDigits! + minID;
204 4159
            const minFD = resolved.minimumFractionDigits ?? minSD - minID;
205
206
            // 6.5. Check for the possibility of the native formatter to have accomplished the desired output
207 4159
            const integerCheck = !ecmaIntegerParts.length || (minID <= ECMA_LIMIT && ecmaIntegerDigits >= minID);
208
            const fractionCheck =
209 4159
                ecmaFractionDigits >= fractionDigits && minFD < ECMA_LIMIT && ecmaFractionDigits >= minFD;
210
211
            // -> if the native formatter is good enough for our decimal value, leave it as-is
212 4159
            if (integerCheck && fractionCheck) {
213 3666
                return ecmaParts as FormatPart[];
214
            }
215
216
            // 6.6. Create the integer value
217 493
            const integerParts = (() => {
218 493
                if (integerCheck) return ecmaIntegerParts;
219
220
                // Expanding the integer part
221 19
                const targetDigits = Math.max(integerDigits, minID);
222
223
                // Creates a base 10 power of the target digits
224 19
                const bigint = BigInt(pow10(targetDigits - 1).toFixed());
225
226
                // Format using the bigint formatter and cut it before joining with the ECMA parts
227 19
                const bigintIntegerParts = bigintFormat.formatToParts(bigint).filter(integerGroups);
228
229
                // We need to replace the first 'one' (from the base 10 power) with a 'zero'
230 19
                bigintIntegerParts[0].value = bigintIntegerParts[0].value.replace(new RegExp(one), zero);
231
232
                // Merge the first part with the bigint part
233 19
                ecmaIntegerParts[0].value =
234
                    bigintIntegerParts[ecmaIntegerParts.length - 1].value.slice(0, -ecmaIntegerParts[0].value.length) +
235
                    ecmaIntegerParts[0].value;
236
237 19
                return [...bigintIntegerParts.slice(0, -ecmaIntegerParts.length), ...ecmaIntegerParts];
238
            })();
239
240
            // 6.7. Create the fraction value
241 493
            const fractionValue = (() => {
242 493
                if (fractionCheck) return ecmaFractionValue;
243
244
                // Simpler formatting if there is actually no fraction
245 492
                if (fraction.eq(0)) {
246 17
                    return plainFormat.format(BigInt(pow10(minFD).toFixed())).slice(1);
247
                }
248
249
                // There are more digits in the number than in the formatting
250 475
                const value = fraction
251
                    .toFixed()
252
                    .slice(2)
253
                    .split("")
254 3163
                    .map(v => numbers[Number(v)])
255
                    .join("");
256
257 475
                if (value.length > maxFD) {
258
                    return fraction
259
                        .toDP(maxFD, rounding)
260
                        .mul(pow10(maxFD))
261
                        .toFixed()
262
                        .split("")
263
                        .map(v => numbers[Number(v)])
264
                        .join("");
265
                }
266
267 475
                if (value.length < minFD) {
268 15
                    return value + zeroFill(minFD - value.length);
269
                }
270
271 460
                return value;
272
            })();
273
274
            // 6.8. Parsing the numeric fragments in a unified part array
275 493
            const result: FormatPart[] = [];
276 493
            let integerDone = false;
277 493
            let fractionDone = false;
278
279 493
            while (ecmaParts.length) {
280 3915
                const { type, value } = ecmaParts.shift()!;
281
282 3915
                if (type === "integer" || type === "group") {
283 2631
                    if (!integerDone) {
284 493
                        integerDone = true;
285 493
                        result.push(...integerParts);
286
                    }
287 2631
                    continue;
288
                }
289
290 1284
                if (type === "fraction") {
291 492
                    if (!fractionDone) {
292 492
                        fractionDone = true;
293 492
                        result.push({ type, value: fractionValue });
294
                    }
295 492
                    continue;
296
                }
297
298 792
                result.push({ type, value });
299
            }
300 493
            return result;
301
        };
302
        //#endregion
303
304 5057
        this.format = value => concatenate(_formatToParts(value));
305 249
        this.formatToParts = value => _formatToParts(value);
306 249
        this.resolvedOptions = () => ({ ...resolved });
307
    }
308
309
    /**
310
     * Returns an array containing the default locales available to the environment, based on a default
311
     * dictionary of locales and regions.
312
     *
313
     * This method is non-standard method that is not available on `Intl` formatters.
314
     *
315
     * @returns Array of strings with the available locales.
316
     */
317
    static supportedLocales() {
318 2
        return SUPPORTED_LOCALES;
319
    }
320
321
    /**
322
     * Returns an array containing those of the provided locales that are supported without having to fall back
323
     * to the runtime's default locale.
324
     *
325
     * @template TNotation Numeric notation of formatting.
326
     * @template TStyle Numeric style of formatting.
327
     * @param locales A string with a BCP 47 language tag, or an array of such strings. For the general form
328
     *   and interpretation of the locales argument, see the [Intl page on
329
     *   MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
330
     * @param options Object used to configure the behavior of the string localization.
331
     * @returns Array of strings with the available locales.
332
     */
333
    static supportedLocalesOf<TNotation extends Notation = "standard", TStyle extends Style = "decimal">(
334
        locales: string | string[],
335
        options?: FormatOptions<TNotation, TStyle>,
336
    ) {
337 2
        return Intl.NumberFormat.supportedLocalesOf(locales, options ? toEcma(options) : undefined) as Locale[];
338
    }
339
}
340
341
// eslint-disable-next-line @typescript-eslint/no-namespace
342
export declare namespace DecimalFormat {
343
    export type {
344
        BaseFormatOptions,
345
        CompactDisplay,
346
        Currency,
347
        CurrencyDisplay,
348
        CurrencySign,
349
        Locale,
350
        LocaleMatcher,
351
        Notation,
352
        NumberingSystem,
353
        FormatOptions,
354
        FormatPart,
355
        FormatPartTypes,
356
        ResolvedFormatOptions,
357
        SignDisplay,
358
        Style,
359
        TrailingZeroDisplay,
360
        Unit,
361
        UnitDisplay,
362
        UseGrouping,
363
    };
364
}
365
366
export default DecimalFormat;
367